/*
 * Unidata Platform Community Edition
 * Copyright (c) 2013-2020, UNIDATA LLC, All rights reserved.
 * This file is part of the Unidata Platform Community Edition software.
 *
 * Unidata Platform Community Edition is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * Unidata Platform Community Edition is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program. If not, see <https://www.gnu.org/licenses/>.
 */
package org.unidata.mdm.meta.service.impl.enumerations;

import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Objects;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Function;
import java.util.stream.Collectors;

import org.apache.commons.collections4.CollectionUtils;
import org.apache.commons.lang3.ArrayUtils;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.stereotype.Component;
import org.unidata.mdm.core.context.ModelChangeContext;
import org.unidata.mdm.core.context.ModelGetContext;
import org.unidata.mdm.core.context.ModelRefreshContext;
import org.unidata.mdm.core.context.ModelRemoveContext;
import org.unidata.mdm.core.dto.ModelGetResult;
import org.unidata.mdm.core.service.CustomPropertiesSupport;
import org.unidata.mdm.core.service.MetaModelService;
import org.unidata.mdm.core.service.impl.AbstractModelComponent;
import org.unidata.mdm.core.type.model.ModelDescriptor;
import org.unidata.mdm.core.type.model.ModelImplementation;
import org.unidata.mdm.core.type.model.ModelSource;
import org.unidata.mdm.core.util.SecurityUtils;
import org.unidata.mdm.meta.configuration.Descriptors;
import org.unidata.mdm.meta.configuration.TypeIds;
import org.unidata.mdm.meta.context.GetEnumerationsContext;
import org.unidata.mdm.meta.context.SourceEnumerationsContext;
import org.unidata.mdm.meta.context.UpsertEnumerationsContext;
import org.unidata.mdm.meta.dao.EnumerationsDAO;
import org.unidata.mdm.meta.dto.GetEnumerationResult;
import org.unidata.mdm.meta.dto.GetEnumerationsResult;
import org.unidata.mdm.meta.exception.MetaExceptionIds;
import org.unidata.mdm.meta.exception.ModelRuntimeException;
import org.unidata.mdm.meta.exception.ModelValidationException;
import org.unidata.mdm.meta.po.EnumerationsPO;
import org.unidata.mdm.meta.serialization.MetaSerializer;
import org.unidata.mdm.meta.service.impl.enumerations.instance.EnumerationsInstanceImpl;
import org.unidata.mdm.meta.service.impl.enumerations.instance.EnumerationsInstanceImpl.EnumerationImpl;
import org.unidata.mdm.meta.type.instance.EnumerationsInstance;
import org.unidata.mdm.meta.type.model.enumeration.EnumerationType;
import org.unidata.mdm.meta.type.model.enumeration.EnumerationValue;
import org.unidata.mdm.meta.type.model.enumeration.EnumerationsModel;
import org.unidata.mdm.meta.util.ModelUtils;
import org.unidata.mdm.system.exception.ValidationResult;

/**
 * @author Mikhail Mikhailov on Oct 8, 2020
 */
@Component(TypeIds.ENUMERATIONS_MODEL)
public class EnumerationsComponent extends AbstractModelComponent implements ModelImplementation<EnumerationsInstance>, CustomPropertiesSupport {
    /**
     * The Constant ENUMERATION_NAME_TOO_LONG.
     */
    private static final String ENUMERATION_NAME_TOO_LONG = "app.meta.enumeration.name.too.long";
    /**
     * The Constant ENUMERATION_NAME_EMPTY.
     */
    private static final String ENUMERATION_NAME_EMPTY = "app.meta.enumeration.name.empty";
    /**
     * Enumeration name invalid (contains invalid characters).
     */
    private static final String ENUMERATION_NAME_INVALID = "app.meta.enumeration.name.invalid";
    /**
     * The Constant ENUMERATION_VALUE_TOO_LONG.
     */
    private static final String ENUMERATION_VALUE_TOO_LONG = "app.meta.enumeration.value.too.long";
    /**
     * The Constant ENUMERATION_VALUE_EMPTY.
     */
    private static final String ENUMERATION_VALUE_EMPTY = "app.meta.enumeration.value.empty";
    /**
     * The Constant ENUMERATION_VALUE_DUPLICATES.
     */
    private static final String ENUMERATION_VALUE_DUPLICATES = "app.meta.enumeration.value.duplicates";
    /**
     * SS DAO.
     */
    @Autowired
    private EnumerationsDAO enumerationsDAO;
    /**
     * The MMS.
     */
    private MetaModelService metaModelService;
    /**
     * SSI. Source systems are singleton chache per storage id.
     */
    private final Map<String, EnumerationsInstance> instances = new ConcurrentHashMap<>(4);
    /**
     * Constructor.
     */
    @Autowired
    public EnumerationsComponent(MetaModelService metaModelService) {
        super();
        this.metaModelService = metaModelService;
        metaModelService.register(this);
    }
    /**
     * {@inheritDoc}
     */
    @Override
    public ModelDescriptor<EnumerationsInstance> descriptor() {
        return Descriptors.ENUMERATIONS;
    }
    /**
     * {@inheritDoc}
     */
    @Override
    public EnumerationsInstance instance(String storageId, String id) {
        return instances.computeIfAbsent(storageId, this::load);
    }
    /**
     * {@inheritDoc}
     */
    @Override
    public ModelGetResult get(ModelGetContext get) {

        GetEnumerationsContext ctx = narrow(get);
        GetEnumerationsResult result = new GetEnumerationsResult();

        EnumerationsInstance i = instance(SecurityUtils.getStorageId(ctx), null);

        processEnumerations(i, ctx, result);
        processInfoFields(i, ctx, result);

        return result;
    }
    /**
     * {@inheritDoc}
     */
    @Override
    public EnumerationsModel assemble(ModelChangeContext ctx) {

        UpsertEnumerationsContext change = narrow(ctx);

        EnumerationsInstance current = instance(SecurityUtils.getStorageId(change), null);
        EnumerationsModel target = new EnumerationsModel();
        if (change.getUpsertType() == ModelChangeContext.ModelChangeType.FULL) {
            target.withValues(change.getEnumerationsUpdate());
        } else {

            Map<String, EnumerationType> merge = current.toSource()
                    .getValues()
                    .stream()
                    .collect(Collectors.toMap(EnumerationType::getName, Function.identity()));

            if (CollectionUtils.isNotEmpty(change.getEnumerationsUpdate())) {
                merge.putAll(
                    change.getEnumerationsUpdate().stream()
                        .collect(Collectors.toMap(EnumerationType::getName, Function.identity())));
            }

            if (CollectionUtils.isNotEmpty(change.getEnumerationsDelete())) {
                change.getEnumerationsDelete().forEach(merge::remove);
            }

            target.withValues(merge.values());
        }

        target
            .withVersion(current.getVersion() + 1)
            .withCreateDate(OffsetDateTime.now())
            .withCreatedBy(SecurityUtils.getCurrentUserName());

        processInfoFields(target, change);

        return target;
    }
    /**
     * {@inheritDoc}
     */
    @Override
    public Collection<ValidationResult> validate(ModelSource src) {

        EnumerationsModel source = narrow(src);

        List<ValidationResult> errors = new ArrayList<>();
        for (EnumerationType en : source.getValues()) {

            // 1. Name
            if (StringUtils.isBlank(en.getName())) {
                String message = "Enumeration's name is blank. Enumeration name must not be blank.";
                errors.add(new ValidationResult(message, ENUMERATION_NAME_EMPTY));
            } else {

                // 2. Shape
                if (!ModelUtils.DEFAULT_MODEL_NAME_PATTERN.matcher(en.getName()).matches()) {
                    String message = "Enumeration's name [{}] is invalid. The name contains invalid characters.";
                    errors.add(new ValidationResult(message, ENUMERATION_NAME_INVALID, en.getName()));
                }

                // 3. Name length
                if(en.getName().length() > 255) {
                    String message = "Enumeration name too long. Enumeration [{}], max length [{}]";
                    errors.add(new ValidationResult(message, ENUMERATION_NAME_TOO_LONG, en.getName(), 255));
                }
            }

            // 4. Values
            errors.addAll(checkValues(en));

            // 5. Custom properties
            errors.addAll(validateCustomProperties(en.getName(), en.getCustomProperties()));
        }

        return errors;
    }
    /**
     * {@inheritDoc}
     */
    public void put(String storageId, EnumerationsModel source) {

        Objects.requireNonNull(storageId, "Storage id must not be null.");

        EnumerationsPO po = new EnumerationsPO();
        po.setCreatedBy(SecurityUtils.getCurrentUserName());
        po.setStorageId(storageId);
        po.setRevision(source.getVersion());
        po.setContent(MetaSerializer.enumerationsToCompressedXml(source));

        try {
            enumerationsDAO.save(po);
        } catch (DuplicateKeyException dke) {
            throw new ModelRuntimeException(
                    "Cannot save enumerations. Revisions conflict [expected next {}].",
                    dke,
                    MetaExceptionIds.EX_META_UPSERT_ENUMERATIONS_REVISION_EXISTS,
                    po.getRevision());
        }
    }
    /**
     * {@inheritDoc}
     */
    @Override
    public void upsert(ModelChangeContext ctx) {

        UpsertEnumerationsContext change = narrow(ctx);
        EnumerationsModel target = assemble(change);

        List<ValidationResult> validations = new ArrayList<>();
        validations.addAll(validate(target));
        validations.addAll(metaModelService.allow(SourceEnumerationsContext.builder()
                    .source(target)
                    .build()));

        if (CollectionUtils.isNotEmpty(validations)) {
            throw new ModelValidationException("Cannot upsert enumerations. Validation errors exist.",
                    MetaExceptionIds.EX_META_UPSERT_ENUMERATIONS_VALIDATION, validations);
        }

        put(SecurityUtils.getStorageId(change), target);

    }
    /**
     * {@inheritDoc}
     */
    @Override
    public void refresh(ModelRefreshContext refresh) {
        instances.computeIfPresent(SecurityUtils.getStorageId(refresh), (k, v) -> load(k));
    }
    /**
     * {@inheritDoc}
     */
    @Override
    public void remove(ModelRemoveContext remove) {
        // Not supported
    }

    private UpsertEnumerationsContext narrow(ModelChangeContext ctx) {

        Objects.requireNonNull(ctx, "Enumerations change input must not be null.");
        if (!StringUtils.equals(ctx.getTypeId(), TypeIds.ENUMERATIONS_MODEL)) {
            throw new ModelRuntimeException("Wrong input type [{}] for enumerations get operation, [{}] expected.",
                    MetaExceptionIds.EX_META_ENUMERATIONS_GET_WRONG_TYPE,
                    ctx.getTypeId(), TypeIds.ENUMERATIONS_MODEL);
        }

        return (UpsertEnumerationsContext) ctx;
    }

    private GetEnumerationsContext narrow(ModelGetContext ctx) {

        Objects.requireNonNull(ctx, "Enumerations get input must not be null.");
        if (!StringUtils.equals(ctx.getTypeId(), TypeIds.ENUMERATIONS_MODEL)) {
            throw new ModelRuntimeException("Wrong input type [{}] for enumerations change operation, [{}] expected.",
                    MetaExceptionIds.EX_META_ENUMERATIONS_CHANGE_WRONG_TYPE,
                    ctx.getTypeId(), TypeIds.ENUMERATIONS_MODEL);
        }

        return (GetEnumerationsContext) ctx;
    }

    private EnumerationsModel narrow(ModelSource src) {

        Objects.requireNonNull(src, "Enumerations source input must not be null.");
        if (!StringUtils.equals(src.getTypeId(), TypeIds.ENUMERATIONS_MODEL)) {
            throw new ModelRuntimeException("Wrong input type [{}] for enumerations source operation, [{}] expected.",
                    MetaExceptionIds.EX_META_ENUMERATIONS_SOURCE_WRONG_TYPE,
                    src.getTypeId(), TypeIds.ENUMERATIONS_MODEL);
        }

        return (EnumerationsModel) src;
    }

    private EnumerationsInstance load(String storageId) {

        EnumerationsPO po = enumerationsDAO.current(storageId);
        if (Objects.isNull(po) || ArrayUtils.isEmpty(po.getContent())) {
            return new EnumerationsInstanceImpl(new EnumerationsModel()
                    .withVersion(0)
                    .withStorageId(storageId));
        }

        return new EnumerationsInstanceImpl(MetaSerializer.enumerationsFromCompressedXml(po.getContent()));
    }

    private Collection<ValidationResult> checkValues(EnumerationType en) {

        List<ValidationResult> errors = new ArrayList<>();
        Map<String, Integer> cardinality = new HashMap<>();
        for (EnumerationValue ev : en.getValues()) {

            if (StringUtils.isBlank(ev.getName())) {
                String message = "Enumeration's value in enumeration [{}] is blank. Enumeration value must not be blank.";
                errors.add(new ValidationResult(message, ENUMERATION_VALUE_EMPTY, en.getName()));
            } else {

                if(ev.getName().length() > 255) {
                    String message = "Enumeration value too long. Enumeration value [{}], max length [{}]";
                    errors.add(new ValidationResult(message, ENUMERATION_VALUE_TOO_LONG, ev.getName(), 255));
                }

                cardinality.compute(ev.getName(), (k, v) -> v == null ? Integer.valueOf(1) : Integer.valueOf(v.intValue() + 1));
            }
        }

        Set<String> duplicates = cardinality.entrySet().stream()
                .filter(entry -> entry.getValue() > 1)
                .map(Entry::getKey)
                .collect(Collectors.toSet());

        if (CollectionUtils.isNotEmpty(duplicates)) {
            String message = "Duplicate enumeration value(s) [{}]";
            errors.add(new ValidationResult(message, ENUMERATION_VALUE_DUPLICATES, duplicates.stream().collect(Collectors.joining(", "))));
        }

        return errors;
    }

    private void processEnumerations(EnumerationsInstance i, GetEnumerationsContext ctx, GetEnumerationsResult dto) {

        if (!ctx.isAllEnumerations() && CollectionUtils.isEmpty(ctx.getEnumerationIds())) {
            return;
        }

        List<GetEnumerationResult> enumerations;
        if (ctx.isAllEnumerations()) {
            enumerations = i.getEnumerations().stream()
                    .map(el -> ((EnumerationImpl) el).getSource())
                    .map(GetEnumerationResult::new)
                    .collect(Collectors.toList());
        } else {
            enumerations = ctx.getEnumerationIds().stream()
                    .map(i::getEnumeration)
                    .filter(Objects::nonNull)
                    .map(el -> ((EnumerationImpl) el).getSource())
                    .map(GetEnumerationResult::new)
                    .collect(Collectors.toList());
        }

        dto.setEnumerations(enumerations);
    }
}
